Vue 组件通信
涵盖 Vue 父子通信、跨组件通信、全局状态管理各种方式,包括 Vue2 / Vue3 差异、provide/inject 响应式处理、事件总线迁移、$attrs 与 props 区别。
一、标准面试回答(1 分钟)
Vue 组件通信的核心是"单向数据流",但根据组件关系(父子、兄弟、跨层级)和场景复杂度,有多种实现方式。常见方式分为三类:父子通信、跨组件通信、全局状态管理。
父子通信
- 父传子:用
props,子组件通过props接收数据 - 子传父:用
$emit触发自定义事件,父组件监听 - 父调子:用
ref获取子组件实例,调用子组件方法或访问数据 - 子调父:也可以通过
$parent访问父组件实例(但耦合度高,不推荐)
跨组件通信(祖孙、兄弟)
- 依赖注入:
provide和inject,祖先组件提供数据,后代组件直接注入,适合跨多级传递;普通值不会自动变成响应式,传入已有的 ref/reactive 对象则可保留响应性 - 事件总线:Vue 2 中用
EventBus(new Vue()作为事件中心),Vue 3 中推荐用mitt或tiny-emitter替代 $attrs/$listeners:用于透传属性和事件,封装高阶组件时很实用
全局状态管理
- Vuex / Pinia:适用于复杂的跨组件共享状态。Pinia 是当前 Vue 官方推荐的新应用状态库;现有 Vuex 项目可以按版本支持与迁移成本决定是否迁移,而不是简单按 Vue 2/3 二分
其他方式
v-model:可理解为 prop 与更新事件的约定语法;Vue 2 组件默认使用value/input,Vue 3 默认使用modelValue/update:modelValue并支持多个v-model- 插槽(slot):父组件向子组件传递模板内容,作用域插槽还能让子组件向父组件回传数据
$root:访问根实例,一般只用于极简 demo,不推荐生产使用
选择原则:优先用 props/$emit 解决父子通信;跨多级用 provide/inject;复杂全局状态用 Pinia/Vuex;简单跨组件用事件总线但要谨慎。
二、20 秒极简版
Vue 组件通信方式:父子用 props/$emit/ref;跨层级用 provide/inject 或 $attrs;兄弟组件可通过共同父组件中转或用事件总线;复杂全局状态用 Pinia/Vuex;还有 v-model、插槽等语法糖方式。
三、分类速查表
| 通信方式 | 适用场景 | Vue 2 / Vue 3 差异 |
|---|---|---|
props / $emit | 父子通信 | 基本一致 |
ref | 父调子 | Vue 3 <script setup> 默认关闭,需 defineExpose |
provide / inject | 跨层级 | 两者都不会把普通值自动变响应式;Vue 3 常直接提供 ref/reactive |
| 事件总线 | 简单跨组件 | Vue 2: new Vue();Vue 3: mitt |
$attrs / $listeners | 属性透传 | Vue 3 合并 $listeners 到 $attrs |
v-model | 父子双向绑定 | Vue 3 支持多个 |
| 作用域插槽 | 子传数据给父渲染 | 语法一致 |
| Vuex / Pinia | 全局状态 | Vue 3 推荐 Pinia |
四、追问 1:provide 和 inject 怎么实现响应式数据传递?
provide / inject 本身不会给普通值添加响应性,但也不会剥离所提供对象已有的响应能力。若要让后代响应变化,应提供 ref、reactive/observable 对象或能读取响应式来源的值,并把修改逻辑尽量留在提供方。
Vue 2 中的做法
传入响应式对象,或者用 Vue.observable 包裹:
// 基础写法(不响应式)
provide() {
return {
user: this.user, // 直接传非响应式
updateUser: this.updateUser
}
}
// 更好的做法:传入响应式对象本身
provide() {
return {
userData: this.$data.userInfo // 传入响应式对象
}
}Vue 3 中的做法
provide 可以直接传入 ref 或 reactive 对象,响应性会自动保持。后代用 inject 拿到后,直接使用即可。如果希望只读,可以配合 readonly 包裹。
// Vue 3 组合式 API
provide('user', userRef) // 传入 ref
const user = inject('user') // 自动保持响应性另外,如果要在 inject 中修改数据,推荐同时 provide 一个修改方法,遵循"单向数据流"原则,避免后代随意修改祖先数据。
五、追问 2:Vue 2 和 Vue 3 中事件总线的实现有什么区别?
Vue 2 中
可以直接创建一个空的 Vue 实例作为事件总线:
// bus.js
export const bus = new Vue()
// 组件 A 发送
bus.$emit('event', data)
// 组件 B 接收
bus.$on('event', handler)需要在组件销毁时用 $off 移除监听,避免内存泄漏。
Vue 3 中
Vue 3 移除了 $on、$off 等实例方法,不再推荐这种模式。官方建议用第三方库如 mitt 或 tiny-emitter 替代。
import mitt from 'mitt'
export const emitter = mitt()
// 发送
emitter.emit('event', data)
// 接收
emitter.on('event', handler)
// 移除
emitter.off('event', handler)事件总线的使用要谨慎:过度使用会让数据流向变得难以追踪,项目复杂度上升后调试困难。一般推荐在小型项目或非核心场景使用,大型应用还是走 Pinia 或 Vuex。
六、追问 3:$attrs 和 props 的区别?什么场景用 $attrs?
核心区别:props 是子组件声明接收的属性,$attrs 是未被组件声明消费的透传属性。Vue 3 中它还可能包含 onXxx 事件监听器;通过 emits 声明的监听器会从 $attrs 排除。Vue 2 的监听器则主要位于 $listeners。
在 Vue 2 中,$attrs 还配合 $listeners(事件监听器)一起使用;Vue 3 中 $listeners 合并到 $attrs 里。
典型场景:封装高阶组件做属性透传
封装一个 BaseButton 组件,它内部是一个原生 <button>,我们希望父组件传给 BaseButton 的所有属性(如 disabled、type、class)都能自动落到原生 <button> 上,不需要在 props 里一个个声明。
<template>
<button v-bind="$attrs">
<slot />
</button>
</template>
<script>
export default {
inheritAttrs: false // 避免根元素自动继承
}
</script>使用 $attrs 可以大大减少 props 的重复声明,让组件封装更干净。配合 inheritAttrs: false 还能控制属性是否自动挂载到根元素上。
七、易错点
- 只列方式不说适用场景:一口气说出 8 种方式但不知道什么时候用哪个
- 混淆 Vue 2 和 Vue 3 的差异:Vue 3 中
$listeners合并到$attrs,事件总线不再内置,Pinia 取代 Vuex 成为官方推荐 - 忽略 provide/inject 的响应性来源:普通值不会自动响应,已有 ref/reactive 对象则会保留响应性
- 事件总线的内存泄漏不提:能主动提到"组件销毁时
$off清理监听" - $attrs 和 props 的概念混淆:有人以为
$attrs是 props 的超集,其实props声明过的属性会从$attrs中移除 - 过度使用全局状态管理:能说出"简单场景优先用 props/$emit,复杂场景再用状态管理"才体现工程判断力
- 忘记 v-model 和插槽也是通信方式:作用域插槽能加分
- 父子通信只提 props/$emit 不提 ref:父组件调用子组件方法也是常见场景
九、前端权限控制完整方案
前端权限控制本质是根据用户角色 / 权限点,控制页面 / 菜单 / 按钮的展示与可操作性。通常分为两层:路由级权限(页面是否能访问)和按钮级权限(操作是否能执行)。
一、整体架构与前后端配合
配合流程:
- 登录:用户登录成功后,后端返回一个 token + 当前用户的权限点列表 / 角色列表(如
['user:add', 'user:edit']或['admin'])。 - 存储:前端可以把 UI 所需权限点放入 Vuex / Pinia;凭据放在哪里要按威胁模型设计。
localStorage中的 token 可被同源脚本读取,存在 XSS 窃取风险,不应把它当成默认安全方案。 - 校验:路由跳转前和组件渲染前,根据权限点判断是否允许。
- 渲染:根据权限点动态生成菜单、过滤路由、控制按钮显示。
二、页面 / 菜单级别权限 & 动态路由(Dynamic Routing)
1. 路由划分
- 静态路由:所有用户都能访问的页面(如登录页、404、个人中心)。
- 动态路由:根据用户权限动态添加的路由(通常来自后端接口返回的菜单树)。
2. 动态路由的两种常见模式
- 后端下发路由表:后端根据用户角色返回对应的完整路由配置(含 component 路径 / component name),前端 addRoute 动态挂载。
- 前端维护所有路由 + 权限过滤:前端维护所有可能的路由,根据权限点过滤后挂载。
3. 核心实现步骤(配合路由守卫)
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem('token')
if (!token) return next('/login')
// 判断是否已经加载过动态路由
if (!store.state.hasLoadedRoutes) {
try {
// 1. 请求后端拿当前用户的路由表
const routes = await api.getUserRoutes()
// 2. 动态挂载
routes.forEach(route => router.addRoute(route))
// 3. 标记已加载
store.commit('setLoadedRoutes', true)
// 4. 重新导航(因为 addRoute 后路由未生效)
next({ ...to, replace: true })
} catch (e) {
next('/login')
}
} else {
next()
}
})三、按钮级别权限 & 自定义指令
1. 编写自定义指令 v-permission
// directives/permission.js
import store from '@/store'
function checkPermission(el, binding) {
// binding.value: ['user:add', 'user:edit'] 或 'user:add'
const required = Array.isArray(binding.value)
? binding.value
: [binding.value]
const userPerms = store.state.user.permissions || []
const hasPermission = required.some(p => userPerms.includes(p))
if (!hasPermission) {
// 移除 DOM 元素
el.parentNode?.removeChild(el)
}
}
export default {
name: 'permission',
inserted: checkPermission,
update: checkFunction // 当表达式变化时重新校验
}全局注册:
import permission from '@/directives/permission'
Vue.directive('permission', permission)2. 在组件中使用
<template>
<div>
<!-- 单权限校验 -->
<el-button v-permission="'user:add'">新增用户</el-button>
<!-- 满足任一权限即可 -->
<el-button v-permission="['user:add', 'user:edit']">编辑用户</el-button>
</div>
</template>3. 特殊情况:非 DOM 元素的权限控制
对于下拉菜单项、Tabs 标签等非 DOM 元素,自定义指令不生效时,可用 v-if + 工具函数:
// utils/auth.js
export function hasPermission(perm) {
const userPerms = store.state.user.permissions || []
return userPerms.includes(perm)
}<el-dropdown-item v-if="hasPermission('user:delete')">删除</el-dropdown-item>总结
- 路由级权限:核心是
router.beforeEach+router.addRoute动态挂载。 - 按钮级权限:优先用
v-permission自定义指令,复杂场景配合v-if + 工具函数。 - 数据权限:列表接口本身就要带权限过滤,前端只是 UI 兜底(永远不要相信前端权限校验)。
十、关联文档
- 08-Vuex 与 Pinia
- 05-Vue ref、reactive、nextTick(ref 调用子组件方法)